Skip to content

feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX#1963

Merged
vanceingalls merged 1 commit into
mainfrom
studio-ux-2-render-cancel-nle
Jul 6, 2026
Merged

feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX#1963
vanceingalls merged 1 commit into
mainfrom
studio-ux-2-render-cancel-nle

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Render cancel, end to end — plus the renders/NLE/storyboard findings from the studio UX review. Before this PR a multi-minute render could not be aborted: the cancelled status existed in the job model but nothing could ever set it, and deleting a job never closed its EventSource.

Changes

Render cancel (critical):

  • POST /render/:jobId/cancel route in @hyperframes/studio-server; RenderJobState gains "cancelled" + a cancel?() hook; SSE/TTL treat any non-rendering status as terminal.
  • Both adapters (cli/studioServer.ts, studio/vite.adapter.ts) create an AbortController and pass its signal through executeRenderJob (the producer already accepted one). Aborted renders delete partial output so cancelled jobs don't resurrect in history, and skip error telemetry.
  • Client: Cancel button on rendering rows, SSE closed on cancel/delete.

Render queue UX:

  • Inline "Delete?/Keep" confirm (ui Button danger) — was a one-click permanent file delete on a 20px target.
  • History load failure shows an error + Retry instead of a false "No renders yet"; delete no longer removes the row when the API fails.
  • "Clear" → "Hide" with per-project persisted hidden ids (previously local-only state whose rows silently resurrected on reload while the per-row X deleted server-side — same surface, opposite permanence).
  • Completed rows keyboard-openable; ≥24px action targets; role="progressbar"; "Last render took 2m 10s" ETA hint from stored durationMs; disabled resolution options explain why ("not an integer scale of 1280×720"); format info is a focusable disclosure; Export uses ui Button with loading + double-fire guard.

NLE:

  • Block drops map through the measured composition size — was hard-coded 1080/1920, so drops landed at the wrong coordinates on any other comp size (critical intent/result gap).
  • Timeline height persisted alongside zoom/pan; divider is a keyboard-resizable role="separator"; initial pan clamped to viewport; zoom HUD updates every frame (was empty/stale); drag-indicator flicker fixed with a depth counter; loading overlay labeled.

Storyboard:

  • beforeunload guard on dirty voiceover (matched the sibling markdown editor); blur autosave unifies the panel's mixed save paradigms; Save shows in-progress state; error state wires the in-scope reload as Retry; SubViewToggle completes the APG tabs contract.

Verification

  • render route tests 25/25 (3 new cancel tests); nle + storyboard + vite adapter suites 45/45
  • studio-server + cli build/typecheck clean

Stack

PR 2/7 of the studio UX-review fixes (stacks on the ui-primitives PR).

🤖 Generated with Claude Code

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX

VERDICT: APPROVE (soft) — strong work across a wide surface. Three items to resolve, one architectural suggestion.


Summary

This is the plumbing + UX half of render cancel: server route, adapter abort hooks, SSE terminal-status handling, partial-output cleanup, and a healthy pile of render queue / NLE / storyboard polish. The cancel lifecycle is well-designed — AbortController at the adapter layer, RenderCancelledError propagation through the producer, optimistic UI in the client, and cleanup of partial files so cancelled jobs don't haunt the history. The NLE and storyboard changes are largely a11y completions (APG tabs, role="separator", role="progressbar", beforeunload guard, blur autosave) and correctness fixes (composition-size-aware drop coordinates, zoom HUD per-frame updates, initial pan clamping).


Ponytail Lens

The cancel lifecycle has a clean separation of concerns: the route marks the job cancelled and invokes the hook, the adapter aborts the controller, the producer throws RenderCancelledError at the next checkpoint, and the catch block cleans up partial output. The double-status-set (route sets "cancelled", catch block re-sets it) is redundant but harmless since they're the same object reference. The executeRenderJob function already has thorough abort support — checkpoint polling via assertNotAborted() at every stage boundary, signal forwarding to parallel capture workers, and typed RenderCancelledError with resource cleanup. This PR correctly plugs into that existing infrastructure rather than reinventing it.

The hidden-ids mechanism for "Hide finished" is appropriately scoped (per-project localStorage key, capped at 200 entries to prevent unbounded growth, defensive JSON parsing). The deleteRender change from fire-and-forget to error-aware is a meaningful reliability improvement — previously a failed delete silently removed the UI row while the file stayed on disk.


Findings

[medium] Side effect inside setState updater — NLEPreview.tsx:183

setCompositionSize((prev) => {
  if (prev?.width === next?.width && prev?.height === next?.height) return prev;
  onCompositionSizeChangeRef.current?.(next);  // <-- side effect in updater
  return next;
});

State updater functions should be pure — React may call them multiple times in Strict Mode (dev) and during concurrent rendering. The onCompositionSizeChange callback sets parent state (setPreviewCompositionSize), which is idempotent, so this won't cause visible bugs in production. But it's a React anti-pattern that could confuse future maintainers or break under stricter concurrent features.

Suggestion: Extract the side effect to a useEffect that watches compositionSize:

const updateCompositionSizeFromPreview = useCallback(() => {
  const next = readPreviewCompositionSize(previewIframeRef.current);
  setCompositionSize((prev) =>
    prev?.width === next?.width && prev?.height === next?.height ? prev : next,
  );
}, []);

useEffect(() => {
  onCompositionSizeChange?.(compositionSize);
}, [compositionSize, onCompositionSizeChange]);

[low] New RenderQueue props unwired in consumer

The props onCancel, loadError, onRetryLoad, actionError, onDismissActionError are all defined as optional in RenderQueueProps and destructured in the component, but StudioRightPanel.tsx (the only consumer) doesn't pass them. The cancelRender, loadError, actionError, dismissActionError, and reloadRenders values from useRenderQueue flow into StudioContext but never reach <RenderQueue>. This means: the cancel button renders but does nothing (calls onCancel?.() which is undefined); load errors and action errors are silently swallowed in the UI.

I assume this wiring lands in a later PR in the 7-PR stack — just flagging to make sure it doesn't get lost.

[low] Missing aria-valuemax on TimelineResizeDivider

The separator declares aria-valuenow and aria-valuemin={MIN_TIMELINE_H} but omits aria-valuemax. Assistive tech can't communicate the full range without it. The max is dynamic (containerHeight - MIN_PREVIEW_H), so it would need computing at render time — an easy addition:

aria-valuemax={Math.round(
  (containerRef.current?.getBoundingClientRect().height ?? 600) - MIN_PREVIEW_H
)}

[nit] Redundant setIsDragOver(true) in handleDragOver

With the new handleDragEnter setting setIsDragOver(true), the same call in handleDragOver (line ~72 in usePreviewBlockDrop.ts) is now redundant. It doesn't cause bugs (browsers fire dragenter before dragover), but removing it would make the intent clearer — dragenter/dragleave own the flag, dragover only handles preventDefault.


Diff Stats

Metric Value
Files changed 19
Additions +823
Deletions -204
New files 1 (TimelineResizeDivider.tsx)
New tests 3 (cancel route)
Test suites touched 1

Review by Miga

🤖 Generated with Claude Code

@vanceingalls vanceingalls force-pushed the studio-ux-2-render-cancel-nle branch from 55b613b to b143798 Compare July 6, 2026 05:24
@vanceingalls vanceingalls force-pushed the studio-ux-1-ui-primitives branch from fb9494d to 679bf1b Compare July 6, 2026 05:24

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stack review for PR 2/7.

Audited: packages/studio-server/src/routes/render.ts, render.test.ts, packages/studio/src/components/renders/useRenderQueue.ts, RenderQueue.tsx, RenderQueueItem.tsx, NLEPreview.tsx, and TimelineResizeDivider.tsx at head b143798.

The server-side cancel path is narrow and test-backed: it marks rendering jobs terminal, invokes the adapter cancel hook, and terminates progress streaming on any non-rendering state. Client-side deletion/cancel error surfacing is materially better than the prior silent removal path, and the render history “hide, not delete” persistence is scoped per project and capped.

One stack-boundary caveat: at this PR boundary, RenderQueue’s onCancel/error props are introduced before StudioRightPanel wires them, so the cancel button is only truly end-to-end once #1964 lands. I verified #1964 performs that wiring. I’m approving this as part of the seven-PR Graphite stack, not as a standalone release boundary. Miga’s note on the side effect inside the NLEPreview state updater is also real but non-blocking here.

Verdict: APPROVE
Reasoning: The cancel/server/client primitives are sound, and the only functional incompleteness is resolved by the immediately-upstack #1964 wiring that is part of this requested stack.

— Magi

@vanceingalls vanceingalls force-pushed the studio-ux-2-render-cancel-nle branch from b143798 to be749f6 Compare July 6, 2026 06:23
@vanceingalls vanceingalls force-pushed the studio-ux-1-ui-primitives branch from 679bf1b to 34dfb3d Compare July 6, 2026 06:23
@vanceingalls vanceingalls force-pushed the studio-ux-2-render-cancel-nle branch from be749f6 to 18e87ac Compare July 6, 2026 06:35
@vanceingalls vanceingalls force-pushed the studio-ux-1-ui-primitives branch from 34dfb3d to 13bc5d2 Compare July 6, 2026 06:35
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Addressed at head 18e87acee: the NLEPreview side effect moved out of the setState updater into an effect (pure updater now), TimelineResizeDivider declares aria-valuemax, and the redundant setIsDragOver(true) in handleDragOver is gone (dragenter/dragleave own the flag). The unwired RenderQueue props are confirmed wired in #1964 as you both noted. A later amend on this branch also fixed the cancel↔completion races found in a follow-up review: both adapters check signal.aborted on resolve (no resurrecting cancelled renders), and the client reconciles with the cancel route's {status} body (no rows stuck on optimistic "Cancelled").

🤖 Generated with Claude Code

miguel-heygen
miguel-heygen previously approved these changes Jul 6, 2026

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current-head re-review at 18e87aceea0d8375618b1d0d16bc535c32ce8617 after Vance's follow-up.

Audited: packages/studio/src/components/nle/NLEPreview.tsx, TimelineResizeDivider.tsx, usePreviewBlockDrop.ts, packages/studio/src/components/renders/useRenderQueue.ts, packages/studio/vite.adapter.ts, and packages/cli/src/server/studioServer.ts at the current head. The NLEPreview composition-size callback has moved out of the state updater into an effect, the timeline separator now declares aria-valuemax, drag-over no longer redundantly owns the hover flag, and the cancel path now reconciles server status plus protects against cancel/complete races in both adapters.

Code checks are green; Graphite mergeability is still pending because this is an upstack PR.

Verdict: APPROVE
Reasoning: The current-head delta resolves the earlier review notes and strengthens the cancel race behavior without introducing a new release blocker; remaining merge gating is stack/Graphite state, not a code finding.

— Magi

@vanceingalls vanceingalls changed the base branch from studio-ux-1-ui-primitives to graphite-base/1963 July 6, 2026 23:07
@vanceingalls vanceingalls force-pushed the studio-ux-2-render-cancel-nle branch from 18e87ac to 25c9c04 Compare July 6, 2026 23:07
@graphite-app graphite-app Bot changed the base branch from graphite-base/1963 to main July 6, 2026 23:08
@graphite-app graphite-app Bot dismissed miguel-heygen’s stale review July 6, 2026 23:08

The base branch was changed.

@vanceingalls vanceingalls force-pushed the studio-ux-2-render-cancel-nle branch from 25c9c04 to 7174f29 Compare July 6, 2026 23:08
@vanceingalls vanceingalls merged commit 241f9d6 into main Jul 6, 2026
42 checks passed
@vanceingalls vanceingalls deleted the studio-ux-2-render-cancel-nle branch July 6, 2026 23:43

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 7174f29.

Note on SHA drift: Magi re-approved at 18e87ace — current merged HEAD is 7174f298, one rebase forward (onto main after #1962 landed as cd1adcb5). Diff since Magi's re-review is the merge context only; no PR-file drift in packages/studio-*, packages/cli/src/server/*, or the render route.

Second-pass lens after Miga's soft-approve rollup + Magi's re-approve at 18e87ac. Confirming Miga's two open items are addressed at HEAD: (a) NLEPreview.tsx:179-190 — the composition-size updater is now pure (setCompositionSize((prev) => …)), and the parent notification lives in a follow-up useEffect keyed on compositionSize; Strict-Mode double-fire path is closed. (b) TimelineResizeDivider.tsx aria-valuemax is present, and both adapters now guard the cancel/complete race (vite.adapter.ts:261-266 + cli/src/server/studioServer.ts:429-434if (signal.aborted) { removeCancelledOutput(); return; } on both the success return path and the catch path).

Two net-new items I didn't see flagged upthread:

Cancel telemetry is silent — 🟠 should-fix (post-merge follow-up)

The PR body explicitly notes cancels "skip error telemetry" — correct choice, cancels aren't failures. But nothing emits a cancel event either. packages/cli/src/server/studioRenderTelemetry.ts ships emitStudioRenderComplete (line 106) + emitStudioRenderError (line 84) but no emitStudioRenderCancel. Both adapters (cli/src/server/studioServer.ts:429-434, studio/vite.adapter.ts:261-266) return early on the abort branch without emitting anything.

Downstream effect: PostHog will show studio_render_start counts that exceed render_complete + render_error{source:"studio"}, and there is no way to close that gap — you can't distinguish "user cancelled mid-render" from "server crashed mid-render" from "browser tab closed" in the data. Given the PR title is render cancel end-to-end, closing the observability loop feels in-scope. Suggest a trackRenderCancel (or reuse trackRenderError with a distinguished errorCode: "user_cancelled") called from the abort branch in both adapters.

End-to-end claim isn't backed by a client-cancel test — 🟡 nit

The three new tests in packages/studio-server/src/routes/render.test.ts:432-505 cover the server route well — cancel marks status, invokes adapter hook, 404 for unknown, no-op if job already complete. Good.

But the client-side cancel path — useRenderQueue.ts:cancelRender (line 268-298), which does optimistic UI + closes SSE + reconciles with the server's authoritative status on res.ok — has no unit tests. The reconcile branch (res.ok && body.status !== "cancelled" → void loadRenders()) is the most valuable piece of that function because it's what saves the user from a stuck-optimistic row when the render actually finished as the cancel POST was in flight, and it's the only piece not verifiable from the route tests. A renderHooks test around useRenderQueue covering (a) cancel with SSE mid-flight, (b) reconcile after a race-completed status, (c) !res.okactionError set — would back the "end-to-end" claim.

Non-findings (verified good)

  • Cancel/complete race — properly preempted in both adapters via abortController.signal.aborted check on both success return AND catch branches.
  • Partial-output cleanup — removeCancelledOutput unlinks both video + .meta.json; renderJobs map keeps the state so SSE readers still see terminal status; cleanupFinishedJobs in render.ts:26-37 sweeps by job.status !== "rendering" so cancelled entries TTL out correctly.
  • SSE terminates cleanly on cancel — client closes on terminal status (useRenderQueue.ts:237-239), server loop exits on next poll iteration when current.status !== "rendering" (render.ts:145).
  • Cancelled files stay off the on-disk history — render.ts:294-303 only registers .mp4/.webm/.mov files that exist, and cancelled runs unlink both the media + meta, so loadRenders doesn't resurrect them post-restart.

Verdict: LGTM from my side — leaving as COMMENT since Magi already stamped. Cancel-telemetry gap is worth a small follow-up PR.

Review by Rames D Jusso

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants